Repository navigation
Rebuild gem native extensions in agent-mode apply (#1996) - #1998
Merged
Mikola Lysenko (mikolalysenko) merged 3 commits intoNov 3, 2026
Merged
Conversation
Agent-mode apply rewrote a gem's ext/**/*.c in place and reported it patched, while Ruby kept loading the extension compiled at install time. When a gem record changes a native-extension build input, apply now writes the patched sources and rebuilds the extension with RubyGems' own builder (the step `gem pristine --extensions` runs), using the Ruby that owns the install. The old build is moved aside first (ExtConfBuilder never overwrites files already in the extension dir) and backed up once inside the gem dir; a marker records the source hashes it was built from and every artifact's hash, including the lib/ copies Ruby may load. - A failed build puts the sources and the previous build back: native_extension_build_failed. - A copy that cannot be rebuilt (precompiled platform gem, no specifications entry, no matching Ruby) is refused with native_extensions_unsupported and nothing is written. - Sources an earlier release patched without a rebuild are rebuilt (native_extension_rebuilt warning). - vex / apply --check attest only when the marker matches (native_extension_not_rebuilt otherwise); hosted copies are unaffected. - rollback restores the backed-up original build, or rebuilds from the restored sources without one (native_extension_restore_failed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wenxin Jiang (Wenxin-Jiang)
approved these changes
Nov 3, 2026
Review fixes: - rollback restores only the gem's own extensions/<plat>/<abi>/<gem> dir under its home; a backup or marker naming any other path (or a symlink) is refused before anything is deleted. - In a gem that declares no extensions, only C sources under ext/ are native inputs (templates and fixtures elsewhere stay plain files); a root-level build script makes only top-level files inputs. - A build whose record cannot be written is undone like a failed one. - The Ruby must build into an installed extension dir; a gem without one (RubyGems < 2.2) is refused. - Lib copies are restored by stage + rename, never rewritten in place under a process that has them mapped. - A build timeout kills the whole process group (Unix). - Probe output tolerates stray stdout lines; the script requires rubygems itself (Ruby 1.8 with RUBYOPT cleared) and names a RubyGems older than 2.2. - Share gemspec_declares_extensions and copy_tree_blocking. --no-gem-native-rebuild (SOCKET_NO_GEM_NATIVE_REBUILD) is for a caller that compiles the extension itself, such as the patches evaluation harness: sources are written without a rebuild, with a native_extension_not_rebuilt warning, and vex does not attest them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko)
marked this pull request as ready for review
November 3, 2026 19:45
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Bugbot Autofix is ON, but none of the reported issues met your autofix severity threshold.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 73a7d0b. Configure here.
- CI (child_deadline_guard): the Ruby probe/build no longer keeps its
own kill_on_drop deadline. New utils::process::build_output_within is
the bounded spawn for builds: stdout and stderr captured, the child in
its own process group (Unix) killed whole at the deadline.
- Bugbot: the lib-copy snapshot, the previous marker, backup.json and
the backed-up lib copies are read through the regular-file guard, so a
FIFO in the gem dir fails fast instead of hanging apply or rollback.
- Bugbot: a dry run over sources that are already patched but whose
extension was not built from them warns native_extension_rebuilt
("apply would rebuild it") instead of reading as plain already_patched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko)
deleted the
fix/gem-native-ext-agent-apply
branch
November 3, 2026 20:25
Merged
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Closes #1996.
Problem
Agent-mode
applyrewrote a gem'sext/**/*.cin place and reported the gem as patched. Ruby never loads those sources: it loads the extension RubyGems compiled at install time, so the vulnerable code kept running (e.g. everyjson2.10.x–2.17.x patch for GHSA-x2f5-4prf-w687).Fix: rebuild (the issue's option 2), with refusal as the fallback
New core module
patch/gem_ext.rs, called from the gem branch ofapply, fromrollback, and from the installed-tree verdict invex::verify..c .h .cc .cpp .cxx .hh .hpp .hxx .m .y .rl,extconf.rb,ext/**/depend, or any file under the directory of a build script the installed gemspec'sextensionsnames (this covers Rust/CMake/Rakefile extensions). A.y/.rlfile in a gem that declares no extensions is not an input, since racc/ragel output ships pre-generated.Gem::Ext::Builder#build_extensionsruns (whatgem pristine --extensionsruns). The Ruby used is the one that owns the install:<prefix>/bin/rubyfor a<prefix>/lib/ruby/gems/<abi>home (never a prefix inside the project), elserubyonPATH, run from--cwdso rbenv/asdf pick the project's Ruby. A candidate is accepted only if its spec resolves to this gem dir and its extension dir is the installed one. The child runs withoutRUBYOPT/RUBYLIB/BUNDLE*, with the gem home first onGEM_PATH.ExtConfBuildernever overwrites files already in the extension dir, so an in-place build would keep the stale.so. The first rebuild also backs up the install-time build (the extension dir plus itslib/copies) to<gem_dir>/.socket-patch-native-orig/.<gem_dir>/.socket-patch-native-build.jsonrecords the source hashes the build used and the hash of every artifact, including thelib/copies. In testing, Ruby actually loaded thelib/copy, not the one underextensions/.native_extension_build_failed, with the tail of the compiler output.specifications/entry, a precompiled platform gem, no matching Ruby), it is refused withnative_extensions_unsupportedand nothing is written. This also happens in--dry-run.native_extension_rebuiltwarning. A copy whose marker already matches isalready_patchedand nothing is rebuilt.apply --check: an installed gem copy with native inputs is attested only while its marker matches the record and every listed artifact. Otherwise it fails withnative_extension_not_rebuilt. Hosted copies are unchanged.native_extension_restore_failed, with agem pristinehint.Codes are documented in
CLI_CONTRACT.md(new "Ruby gems with native extensions in agent mode" section), anddocs/ecosystems.mdgets a "Ruby gems: native extensions" section.Testing
tests/e2e_gem_native_ext_build.rsbuilds a real C-extension gem (SpNative.valuegoes from 1 to 2):--checkclean → re-apply skipped → rollback → 1, marker and backup gone--checkfails withnative_extension_not_rebuilt→ apply rebuildsPATH→ refused (dry run too); gem without extensions → refused; Ruby-only patch to a native gem → applies without Ruby; stale build →--checkfails.#[ignore]d like the other Ruby suites and added to the Ruby 3.4 PR leg and the Ruby 3.1 e2e-full leg (SOCKET_PATCH_BUNDLER_E2E_REQUIREDmakes a missing toolchain fail there).json2.10.2 gem (two extensions) on macOS/Ruby 3.4: patchedparser.c→ Ruby raises the patched error message,apply --checkis clean (also after moving the project dir), androllbackrestores the original message.e2e_gem,in_process_gem_apply,e2e_redirect_gem_stale_install,e2e_vex_redirect,help_text_hygieneandcliall pass.cargo fmtis clean, and clippy is clean on every touched file.depscan and the patches harness
apply.applyon gems (apply --manifest-path … --global --global-prefix $GEM_HOME/gems --offline --force), then recompiles the extension itself, with era-specific fallbacks:DISABLE_SSL, an era OpenSSL, nokogiri port-archive restore. Its worker builds socket-patch from depscan's pinned submodule, so this change reaches it only on a pin bump.--no-gem-native-rebuild/SOCKET_NO_GEM_NATIVE_REBUILDlets a caller that compiles the extension itself keep the old behavior: sources are written, no Ruby is run, and the run warnsnative_extension_not_rebuilt. Patchpedia can set the env var before the bump; the currently pinned binary ignores it.ruby-full(Ruby 3.1.2 / RubyGems 3.3.15, what the eval workers run) on the realjson2.10.2 gem, using a Linux build of this branch:rubygemsitself, since Ruby 1.8 doesn't load it andRUBYOPTis cleared.Code review
/code-review highreported 10 findings. 9 are fixed in 73a7d0b:ext/in a gem with no extensions are no longer refused.Not fixed: "apply now runs the gem's build scripts". That's inherent to rebuilding, and it's what
gem install/bundle installalready ran for that project.Known limits
redirect_gem_platform_unsupported). The refusal message points at the ruby-platform gem.🤖 Generated with Claude Code
Note
Medium Risk
Agent-mode apply now invokes Ruby and the gem's own build scripts against installed trees, which expands blast radius if rebuild or restore logic is wrong; attestation rules also change for native gem patches.
Overview
Agent-mode
applyno longer stops at rewriting a gem'sext/sources. When a patch touches native-extension build inputs, it now rebuilds the extension with RubyGems' builder (same path asgem pristine --extensions) so Ruby loads patched compiled code, not stale.soartifacts.The flow refuses installs that can't be rebuilt in place (Bundler git checkouts, precompiled platform gems, no matching Ruby), backs up the install-time build under
.socket-patch-native-orig/, and writes.socket-patch-native-build.jsonsovexandapply --checkonly attest copies whose built artifacts match the patched sources. Failed builds roll sources and the old build back;rollbackrestores the backup (or rebuilds when needed). Callers that compile themselves can use--no-gem-native-rebuild/SOCKET_NO_GEM_NATIVE_REBUILDto keep source-only writes with anative_extension_not_rebuiltwarning.Docs (
CLI_CONTRACT.md,docs/ecosystems.md) and CI add thee2e_gem_native_ext_buildcapstone on pinned Ruby matrix legs.Reviewed by Cursor Bugbot for commit 73a7d0b. Configure here.